// ==UserScript==
// @name         Geocaching - Zip UserSuppliedContent Images
// @namespace    https://out14nd3r.com/
// @version      1.9
// @description  Download all non-thumbnail images from UserSuppliedContent into a ZIP file. Button only appears when images exist.
// @author       OUT14ND3R
// @match        https://www.geocaching.com/geocache/*
// @match        https://www.geocaching.com/seek/cache_details.aspx*
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @connect      *
// @require      https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.7.60/dist/zip.min.js
// ==/UserScript==

(function () {
    'use strict';

    const BUTTON_ID = 'gc-usc-image-zip-button';
    const BOX_ID = 'gc-usc-image-zip-box';

    function findUSC() {
        return (
            document.querySelector('#ctl00_ContentBody_LongDescription') ||
            document.querySelector('.UserSuppliedContent') ||
            document.querySelector('[class*="UserSuppliedContent"]') ||
            document.querySelector('#UserSuppliedContent')
        );
    }

    function isImageUrl(url) {
        return /\.(jpg|jpeg|png|gif|webp|bmp)(\?|#|$)/i.test(url || '');
    }

    function isThumbnail(img) {
        const src = img.currentSrc || img.src || '';
        const width = img.naturalWidth || img.width || 0;
        const height = img.naturalHeight || img.height || 0;
        const link = img.closest('a');
        const href = link ? link.href : '';

        // If a small image links to a full-size image, keep it.
        if (href && href !== src && isImageUrl(href)) return false;

        // Skip small images.
        if (width && height && (width < 150 || height < 150)) return true;

        // Skip obvious thumbnail/icon URLs.
        if (/thumb|thumbnail|small|avatar|icon|sprite/i.test(src)) return true;

        return false;
    }

    function getBestImageUrl(img) {
        const src = img.currentSrc || img.src || '';
        const link = img.closest('a');
        const href = link ? link.href : '';

        if (href && isImageUrl(href)) return href;
        return src;
    }

    function getImageUrlsFromUSC() {
        const usc = findUSC();
        if (!usc) return [];

        return [...new Set(
            Array.from(usc.querySelectorAll('img'))
                .filter(img => !isThumbnail(img))
                .map(getBestImageUrl)
                .filter(Boolean)
        )];
    }

    function cleanFilename(name) {
        return name
            .replace(/[\\/:*?"<>|]/g, '_')
            .replace(/\s+/g, '_')
            .replace(/^_+|_+$/g, '')
            .slice(0, 120);
    }

    function filenameFromUrl(url, index, blob) {
        try {
            const u = new URL(url);
            let base = decodeURIComponent(u.pathname.split('/').pop() || `image_${index}`);
            base = cleanFilename(base);

            if (!/\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(base)) {
                const type = blob?.type || '';
                if (type.includes('png')) base += '.png';
                else if (type.includes('gif')) base += '.gif';
                else if (type.includes('webp')) base += '.webp';
                else if (type.includes('bmp')) base += '.bmp';
                else base += '.jpg';
            }

            return `${String(index).padStart(2, '0')}_${base}`;
        } catch {
            return `${String(index).padStart(2, '0')}_image.jpg`;
        }
    }

    function fetchBlob(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url,
                responseType: 'blob',
                timeout: 45000,
                onload: response => {
                    if (response.status >= 200 && response.status < 300) {
                        resolve(response.response);
                    } else {
                        reject(new Error(`HTTP ${response.status}: ${url}`));
                    }
                },
                onerror: () => reject(new Error(`Failed to fetch: ${url}`)),
                ontimeout: () => reject(new Error(`Timeout: ${url}`))
            });
        });
    }

    function downloadBlob(blob, filename) {
        const blobUrl = URL.createObjectURL(blob);

        GM_download({
            url: blobUrl,
            name: filename,
            saveAs: true,
            onload: () => {
                setTimeout(() => URL.revokeObjectURL(blobUrl), 5000);
            },
            onerror: () => {
                const a = document.createElement('a');
                a.href = blobUrl;
                a.download = filename;
                document.body.appendChild(a);
                a.click();
                a.remove();

                setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
            }
        });
    }

    function getCacheCode() {
        const candidates = [];

        const metaUrl =
            document.querySelector('meta[property="og:url"]')?.content ||
            document.querySelector('link[rel="canonical"]')?.href ||
            location.href;

        candidates.push(metaUrl);

        document.querySelectorAll('a[href*="/geocache/"], a[href*="wp=GC"], a[href*="gccode=GC"]').forEach(a => {
            candidates.push(a.href || '');
            candidates.push(a.textContent || '');
        });

        candidates.push(document.title || '');
        candidates.push(document.body.textContent || '');

        for (const text of candidates) {
            const match = String(text).match(/\bGC[A-Z0-9]{3,8}\b/i);
            if (match) return match[0].toUpperCase();
        }

        return 'GCUNKNOWN';
    }

    async function zipUSCImages(button) {
        const urls = getImageUrlsFromUSC();

        if (!urls.length) {
            const box = document.getElementById(BOX_ID);
            if (box) box.remove();
            return;
        }

        const originalText = button.textContent;
        button.disabled = true;

        let zipWriter = null;
        const failures = [];
        let added = 0;
        let totalBytes = 0;

        try {
            button.textContent = 'Starting ZIP...';

            zip.configure({
                useWebWorkers: true
            });

            zipWriter = new zip.ZipWriter(
                new zip.BlobWriter('application/zip'),
                {
                    level: 0
                }
            );

            for (let i = 0; i < urls.length; i++) {
                const url = urls[i];

                try {
                    button.textContent = `Fetching ${i + 1}/${urls.length}...`;

                    const blob = await fetchBlob(url);
                    totalBytes += blob.size || 0;

                    if (totalBytes > 300 * 1024 * 1024) {
                        failures.push(`Skipped after 300MB limit: ${url}`);
                        continue;
                    }

                    const filename = filenameFromUrl(url, i + 1, blob);

                    button.textContent = `Adding ${i + 1}/${urls.length}...`;

                    await zipWriter.add(
                        filename,
                        new zip.BlobReader(blob),
                        {
                            level: 0
                        }
                    );

                    added++;

                } catch (err) {
                    console.warn(err);
                    failures.push(url);
                }

                await new Promise(resolve => setTimeout(resolve, 50));
            }

            if (failures.length) {
                button.textContent = 'Adding error list...';

                const failBlob = new Blob(
                    [failures.join('\n')],
                    { type: 'text/plain' }
                );

                await zipWriter.add(
                    'failed-images.txt',
                    new zip.BlobReader(failBlob),
                    {
                        level: 0
                    }
                );
            }

            if (!added) {
                alert('No images could be added to the ZIP.');
                return;
            }

            button.textContent = 'Finishing ZIP...';

            const zipBlob = await zipWriter.close();

            button.textContent = 'Downloading...';

            const cacheCode = getCacheCode();
            downloadBlob(zipBlob, `${cacheCode}_UserSuppliedContent_images.zip`);

        } catch (err) {
            console.error(err);
            alert('ZIP failed. Try fewer images or check the browser console for details.');

            if (zipWriter) {
                try {
                    await zipWriter.close();
                } catch (_) {}
            }
        } finally {
            button.textContent = originalText;
            button.disabled = false;
        }
    }

    function makeFloatingButtonBox(imageCount) {
        const box = document.createElement('div');
        box.id = BOX_ID;

        box.style.position = 'fixed';
        box.style.left = '18px';
        box.style.bottom = '18px';
        box.style.zIndex = '99999';
        box.style.width = '210px';
        box.style.padding = '10px';
        box.style.border = '1px solid #555';
        box.style.borderRadius = '10px';
        box.style.background = '#ffffff';
        box.style.boxShadow = '0 3px 12px rgba(0,0,0,0.25)';
        box.style.fontFamily = 'Arial, sans-serif';

        const title = document.createElement('div');
        title.textContent = 'USC Images';
        title.style.fontWeight = 'bold';
        title.style.marginBottom = '4px';
        title.style.fontSize = '14px';
        title.style.color = '#111';

        const note = document.createElement('div');
        note.textContent = `${imageCount} image${imageCount === 1 ? '' : 's'} found`;
        note.style.fontSize = '12px';
        note.style.color = '#666';
        note.style.marginBottom = '8px';

        const button = document.createElement('button');
        button.id = BUTTON_ID;
        button.textContent = 'Download ZIP';

        button.style.display = 'block';
        button.style.width = '100%';
        button.style.padding = '8px 10px';
        button.style.border = '1px solid #333';
        button.style.borderRadius = '6px';
        button.style.background = '#f3f3f3';
        button.style.color = '#111';
        button.style.cursor = 'pointer';
        button.style.fontWeight = 'bold';
        button.style.textAlign = 'center';
        button.style.fontSize = '13px';

        button.addEventListener('click', () => zipUSCImages(button));

        box.appendChild(title);
        box.appendChild(note);
        box.appendChild(button);

        return box;
    }

    function addButtonIfImagesExist() {
        if (document.getElementById(BUTTON_ID) || document.getElementById(BOX_ID)) return;

        const urls = getImageUrlsFromUSC();

        // If no non-thumbnail images are found, do not show the button.
        if (!urls.length) return;

        document.body.appendChild(makeFloatingButtonBox(urls.length));
    }

    function waitForPage() {
        let tries = 0;

        const timer = setInterval(() => {
            tries++;

            addButtonIfImagesExist();

            if (document.getElementById(BUTTON_ID) || tries > 40) {
                clearInterval(timer);
            }
        }, 500);
    }

    waitForPage();

})();